spb/groupe-ka Public
Groupe KA — site du holding + KA ID (compte unique & SSO des 7 plateformes). Next.js 16, SQLite, Google & Apple login.
TypeScript 89.7%
CSS 5.8%
HTML 4.4%
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// Profil public d'un membre (opt-in) — groupe-ka.com/u/{ka_id}.3// Affiche nom, photo, statut, bio, emploi, ville, âge, site et réseaux —4// JAMAIS le courriel ni le téléphone. 404 indistinct si privé/inexistant.5import type { Metadata } from "next";6import {7 db,8 parseSocials,9 ageFromBirthDate,10 type UserRow,11} from "@/lib/db";12import { roleCard } from "@/lib/roles";1314export const metadata: Metadata = {15 title: "Profil de membre | Groupe KA",16 description: "Profil public d'un membre du Groupe KA.",17};1819export const dynamic = "force-dynamic";2021function fmtDate(iso: string | null): string {22 if (!iso) return "—";23 const d = new Date(iso.includes("T") ? iso : iso.replace(" ", "T") + "Z");24 if (Number.isNaN(d.getTime())) return "—";25 return d26 .toLocaleDateString("fr-CA", { month: "long", year: "numeric" });27}2829const SOCIAL_LABELS: Record<string, string> = {30 instagram: "Instagram",31 facebook: "Facebook",32 x: "X",33 linkedin: "LinkedIn",34 tiktok: "TikTok",35 youtube: "YouTube",36};3738function socialHref(key: string, value: string): string {39 if (/^https?:\/\//i.test(value)) return value;40 const handle = value.replace(/^@/, "");41 const base: Record<string, string> = {42 instagram: "https://instagram.com/",43 facebook: "https://facebook.com/",44 x: "https://x.com/",45 linkedin: "https://www.linkedin.com/in/",46 tiktok: "https://www.tiktok.com/@",47 youtube: "https://www.youtube.com/",48 };49 return (base[key] ?? "https://") + handle;50}5152export default async function ProfilPublic({53 params,54}: {55 params: Promise<{ kaId: string }>;56}) {57 const { kaId } = await params;58 const clean = /^ka-\d{10}$/.test(kaId) ? kaId : null;59 const row = clean60 ? (db.prepare("SELECT * FROM users WHERE ka_id = ?").get(clean) as61 | UserRow62 | undefined)63 : undefined;6465 // privé ou inexistant : indistinguable, volontairement66 if (!row || !row.public) {67 return (68 <main className="mx-auto max-w-md px-4 pb-16 sm:px-6">69 <section className="pt-12 text-center sm:pt-14">70 <p className="kicker rise justify-center">Profil de membre</p>71 <h1 className="gk-display rise mt-4 text-[clamp(28px,5vw,40px)] leading-[1.02] font-bold tracking-[-0.035em] uppercase [animation-delay:0.08s]">72 Profil <span className="outline-txt">introuvable</span>73 </h1>74 <p className="rise mt-4 text-[14px] text-ink-2 [animation-delay:0.16s]">75 Ce profil n'existe pas ou n'est pas public.76 </p>77 </section>78 </main>79 );80 }8182 const socials = parseSocials(row.socials);83 const age = ageFromBirthDate(row.birth_date);84 const facts: [string, string][] = [85 ...(row.job_title86 ? ([["Emploi", row.company ? `${row.job_title} · ${row.company}` : row.job_title]] as [string, string][])87 : row.company88 ? ([["Entreprise", row.company]] as [string, string][])89 : []),90 ...(row.city ? ([["Ville", row.city]] as [string, string][]) : []),91 ...(age !== null ? ([["Âge", `${age} ans`]] as [string, string][]) : []),92 ["Membre depuis", fmtDate(row.created_at)],93 ["KA-ID", row.ka_id ?? "—"],94 ];9596 return (97 <main className="mx-auto max-w-md px-4 pb-16 sm:px-6">98 <section className="pt-10 text-center sm:pt-12">99 <p className="kicker rise justify-center">100 Profil de membre · Groupe KA101 </p>102103 <div className="rise mt-7 flex justify-center [animation-delay:0.08s]">104 {row.avatar_url ? (105 // eslint-disable-next-line @next/next/no-img-element106 <img107 src={row.avatar_url}108 alt={`Photo de ${row.name}`}109 width={176}110 height={176}111 referrerPolicy="no-referrer"112 className="h-44 w-44 rounded-full border-[3px] border-ink object-cover shadow-[8px_8px_0_rgba(20,24,20,0.18)]"113 />114 ) : (115 <span className="gk-display flex h-44 w-44 items-center justify-center rounded-full border-[3px] border-ink bg-lime text-[64px] font-bold shadow-[8px_8px_0_rgba(20,24,20,0.18)]">116 {row.name.charAt(0).toUpperCase()}117 </span>118 )}119 </div>120121 <h1 className="gk-display rise mt-6 text-[clamp(28px,6vw,42px)] leading-[1.02] font-bold tracking-[-0.03em] [animation-delay:0.14s]">122 {row.name}123 </h1>124 {roleCard(row.role) && (125 <p className="rise mt-3 [animation-delay:0.18s]">126 <span className="gk-mono inline-block -rotate-1 rounded-[5px] bg-ink px-3 py-[4px] text-[10.5px] font-bold tracking-[0.12em] text-lime">127 {roleCard(row.role)}128 </span>129 </p>130 )}131 {row.bio && (132 <p className="rise mx-auto mt-4 max-w-sm text-[14.5px] leading-relaxed text-ink-2 [animation-delay:0.22s]">133 {row.bio}134 </p>135 )}136137 <div className="gk-card rise mt-7 p-0 text-left [animation-delay:0.26s]">138 {facts.map(([label, value], i) => (139 <div140 key={label}141 className={`flex items-baseline justify-between gap-4 px-5 py-[13px] ${i < facts.length - 1 ? "border-b border-dashed border-[rgba(20,24,20,0.18)]" : ""}`}142 >143 <span className="klabel flex-none">{label}</span>144 <span145 className={`text-right text-[13.5px] font-semibold ${label === "KA-ID" ? "gk-mono text-green" : ""}`}146 >147 {value}148 </span>149 </div>150 ))}151 </div>152153 {(row.website || Object.keys(socials).length > 0) && (154 <div className="rise mt-5 flex flex-wrap justify-center gap-2 [animation-delay:0.32s]">155 {row.website && (156 <a157 href={row.website}158 target="_blank"159 rel="noopener noreferrer nofollow"160 className="stat-chip !text-[11.5px] hover:bg-lime-soft"161 >162 🌐 Site web163 </a>164 )}165 {Object.entries(socials).map(([k, v]) => (166 <a167 key={k}168 href={socialHref(k, v)}169 target="_blank"170 rel="noopener noreferrer nofollow"171 className="stat-chip !text-[11.5px] hover:bg-lime-soft"172 >173 {SOCIAL_LABELS[k] ?? k}174 </a>175 ))}176 </div>177 )}178179 <p className="mt-6 text-[12px] text-ink-2">180 Profil publié volontairement par ce membre —{" "}181 <a href={`/m/${row.ka_id}`} className="underline underline-offset-4">182 vérifier son KA-ID183 </a>184 . Courriel et téléphone jamais affichés.185 </p>186 </section>187 </main>188 );189}190